Hesapları Kapatma ve Canlandırma Saldırıları
Özet
- Bir hesabı kapatmak, yeniden başlatma/canlandırma saldırıları için bir fırsat yaratır.
- Solana çalışma zamanı, hesapların artık kira muafiyeti sağlamadığında çöp toplar. Hesapları kapatmak, hesapta depolanan lamportların kira muafiyeti için başka bir hesaba aktarılmasını içerir.
- Hesapları güvenli bir şekilde kapatmak ve hesap ayırıcıyı
CLOSED_ACCOUNT_DISCRIMINATOR
olarak ayarlamak için Anchor#[account(close = )]
kısıtlamasını kullanabilirsiniz.#[account(mut, close = receiver)]
pub data_account: Account<'info, MyData>,
#[account(mut)]
pub receiver: SystemAccount<'info>
Ders
Basit gelmesine rağmen, hesapları doğru bir şekilde kapatmak karmaşık olabilir. Belirli adımları takip etmezseniz, bir saldırganın hesabın kapanmasını atlatmanın birçok yolu vardır.
Taktik: Bu saldırı vektörlerini daha iyi anlamak için her bir senaryoyu derinlemesine inceleyelim. — Dikkatli olun!
Güvensiz hesap kapama
Temelinde, bir hesabı kapatmak, lamportlarını ayrı bir hesaba aktarmayı içerir ve bu, Solana çalışma zamanının ilk hesabı çöp toplamasını tetikler. Bu, sahiplikten sistem programına geçişi sıfırlar.
Aşağıdaki örneğe göz atın. Talimat iki hesaba ihtiyaç duyar:
account_to_close
- kapanacak hesapdestination
- kapanan hesabın lamportlarını alacak hesap
Program mantığı, destination
hesabının lamportlarını account_to_close
’da depolanan miktarla artırarak hesabı kapatmayı amaçlar ve account_to_close
'un lamportlarını 0'a ayarlar. Bu programda, tam bir işlem işlemi tamamlandıktan sonra account_to_close
çalışma zamanı tarafından çöp toplanacaktır.
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod closing_accounts_insecure {
use super::*;
pub fn close(ctx: Context<Close>) -> ProgramResult {
let dest_starting_lamports = ctx.accounts.destination.lamports();
**ctx.accounts.destination.lamports.borrow_mut() = dest_starting_lamports
.checked_add(ctx.accounts.account_to_close.to_account_info().lamports())
.unwrap();
**ctx.accounts.account_to_close.to_account_info().lamports.borrow_mut() = 0;
Ok(())
}
}
#[derive(Accounts)]
pub struct Close<'info> {
account_to_close: Account<'info, Data>,
destination: AccountInfo<'info>,
}
#[account]
pub struct Data {
data: u64,
}
Ancak, çöp toplama işlemi, işlem tamamlanana kadar gerçekleşmez. Ve bir işlemede birden fazla talimat olabileceğinden, bu bir saldırgan için hesabı kapatma talimatını çağırma fırsatı yaratır, ancak ayrıca işlemde hesabın kira muafiyet lamportlarını iade etmek için bir transfer de ekleyebilir. Sonuç olarak, hesap çöp toplanmayacak, bu da saldırganın programda beklenmeyen davranışlar oluşturmasına ve hatta bir protokolden belirli bir miktarı boşaltmasına yol açacaktır.
Güvenli hesap kapama
Bu açığı kapatmanın en önemli iki yolu, hesap verilerini sıfırlamak ve hesabın kapandığını belirten bir hesap ayırıcı eklemektir. İkisinin de olması, istenmeyen program davranışlarını önlemek için gereklidir.
Verileri sıfırlanmış bir hesap hâlâ bazı şeyler için kullanılabilir, özellikle de adres türetimi program içinde doğrulama amaçları için kullanılıyorsa. Ancak, saldırganın önceki depolanan verilere erişememesi durumunda zarar potansiyel olarak sınırlı olabilir.
İpucu: Programı daha güvenli hale getirmek için, kapatılan hesaplara "kapalı" olarak belirten bir hesap ayırıcı eklenmeli ve tüm talimatlar tüm geçirilen hesaplar üzerinde kontroller yapmalı ve hesap kapalı olarak işaretlenmişse bir hata döndürmelidir.
Aşağıdaki örneğe bakın. Bu program, bir hesaptan lamportları transfer ediyor, hesap verilerini sıfırlıyor ve çöp toplanmadan önce bu hesabın tekrar kullanılmasını önlemek umuduyla tek bir talimatla bir hesap ayırıcı ayarlıyor. Bu işlemlerden herhangi birinin eksik olması, bir güvenlik açığına yol açacaktır.
use anchor_lang::prelude::*;
use std::io::Write;
use std::ops::DerefMut;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod closing_accounts_insecure_still_still {
use super::*;
pub fn close(ctx: Context<Close>) -> ProgramResult {
let account = ctx.accounts.account.to_account_info();
let dest_starting_lamports = ctx.accounts.destination.lamports();
**ctx.accounts.destination.lamports.borrow_mut() = dest_starting_lamports
.checked_add(account.lamports())
.unwrap();
**account.lamports.borrow_mut() = 0;
let mut data = account.try_borrow_mut_data()?;
for byte in data.deref_mut().iter_mut() {
*byte = 0;
}
let dst: &mut [u8] = &mut data;
let mut cursor = std::io::Cursor::new(dst);
cursor
.write_all(&anchor_lang::__private::CLOSED_ACCOUNT_DISCRIMINATOR)
.unwrap();
Ok(())
}
}
#[derive(Accounts)]
pub struct Close<'info> {
account: Account<'info, Data>,
destination: AccountInfo<'info>,
}
#[account]
pub struct Data {
data: u64,
}
Yukarıdaki örneğin Anchor'ın CLOSED_ACCOUNT_DISCRIMINATOR
kullandığını unutmayın. Bu, her bir baytın 255
olduğu yalnızca bir hesap ayırıcıdır. Ayırıcı herhangi bir doğrudan anlam taşımamakla birlikte, kapalı hesapların bir talimata geçirildiğinde her zaman hata döndüren hesap doğrulama kontrolleriyle birleştirirseniz, programınızın kapalı bir hesapla yanlışlıkla bir talimat işleme almasını durdurursunuz.
Manuel Zorla Fon Kesme
Hala bir küçük sorun var. Hesap verilerini sıfırlama ve "kapalı" hesap ayırıcı ekleme uygulaması, programınızın kötüye kullanımını durduracak olsa da, bir kullanıcı, bir talimatın sonunda hesabın lamportlarını iade ederek hesabın çöp toplanmasını önleyebilir. Bu, kullanılmayan ama aynı zamanda çöp toplanamayan bir veya potansiyel olarak birçok hesabın var olduğu bir limbo durumuna yol açar.
Bu kenar durumu ele almak için, "kapalı" hesap ayırıcı ile etiketlenen hesapları fona zorla kesmeye olanak tanıyan bir talimat eklemeyi düşünebilirsiniz. Bu talimat yalnızca kesilen hesabın kapalı olarak işaretlendiğini doğrulamak için hesap doğrulaması yapar. Bu şu şekilde görünebilir:
use anchor_lang::__private::CLOSED_ACCOUNT_DISCRIMINATOR;
use anchor_lang::prelude::*;
use std::io::{Cursor, Write};
use std::ops::DerefMut;
...
pub fn force_defund(ctx: Context<ForceDefund>) -> ProgramResult {
let account = &ctx.accounts.account;
let data = account.try_borrow_data()?;
assert!(data.len() > 8);
let mut discriminator = [0u8; 8];
discriminator.copy_from_slice(&data[0..8]);
if discriminator != CLOSED_ACCOUNT_DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}
let dest_starting_lamports = ctx.accounts.destination.lamports();
**ctx.accounts.destination.lamports.borrow_mut() = dest_starting_lamports
.checked_add(account.lamports())
.unwrap();
**account.lamports.borrow_mut() = 0;
Ok(())
}
...
#[derive(Accounts)]
pub struct ForceDefund<'info> {
account: AccountInfo<'info>,
destination: AccountInfo<'info>,
}
Bu talimat herhangi biri tarafından çağrılabileceğinden, bu, bir saldırganın hesap kira muafiyetini ödeyerek yeniden canlandırma saldırılarını engelleyebilir, ancak başka biri iade edilen hesapta bulunan lamportları kendileri için talep edebilir.
Gerekli olmasa da, "limbo" hesaplarla ilişkili alan ve lamport israfını elimine etmeye yardımcı olabilir.
Anchor close
Kısıtlamasını Kullanma
Neyse ki, Anchor tüm bunları #[account(close = )]
kısıtlaması ile çok daha basit hale getiriyor. Bu kısıtlama, bir hesabı güvenli bir şekilde kapatmak için gereken her şeyi halleder:
- Hesabın lamportlarını belirli bir ``'a aktarır
- Hesap verilerini sıfırlar
- Hesap ayırıcısını
CLOSED_ACCOUNT_DISCRIMINATOR
varyantına ayarlar
Yapmanız gereken tek şey, kapatılmasını istediğiniz hesaba hesap doğrulama yapısına eklemektir:
#[derive(Accounts)]
pub struct CloseAccount {
#[account(
mut,
close = receiver
)]
pub data_account: Account<'info, MyData>,
#[account(mut)]
pub receiver: SystemAccount<'info>
}
force_defund
talimatı isteğe bağlı bir ek olup, kullanmak isterseniz kendi başınıza gerçekleştirmeniz gerekecektir.
Laboratuvar
Bir saldırganın canlandırma saldırısından nasıl yararlanabileceğini netleştirmek için, kullanıcıların piyangoya katılımını yönetmek üzere program hesap durumunu kullanan basit bir piyango programıyla çalışalım.
1. Kurulum
starter
dalından kodu alın
şu repodan.
Kodda programda iki talimat ve tests
dizininde iki test bulunmaktadır.
Program talimatları:
enter_lottery
redeem_rewards_insecure
Bir kullanıcı enter_lottery
çağrıldığında, program, kullanıcının piyango girişine dair bir durumu depolamak için bir hesap başlatacaktır.
Bu tamamen gelişmiş bir piyango programı yerine basit bir örnek olduğu için, bir kullanıcı piyangoya girdikten sonra dilediği zaman redeem_rewards_insecure
talimatını çağırabilir. Bu talimat, kullanıcıya ödülleri, kullanıcının piyangoya katıldığı kadar miktarda Token oluşturur. Ödülleri oluşturdıktan sonra program, kullanıcının piyango girişini kapatır.
Not: Program koduna aşina olmak için bir dakikanızı ayırın.
enter_lottery
talimatı, kullanıcıya bağlı bir PDA'da bir hesap oluşturur ve üzerinde bazı durumları başlatır.
redeem_rewards_insecure
talimatı bazı hesap ve veri doğrulamaları gerçekleştirir, verilen token hesabına jetonlar çıkarır ve ardından piyango hesabını lamportlarını kaldırarak kapatır.
Ancak, redeem_rewards_insecure
talimatının yalnızca hesabın lamportlarını dışarı aktardığını ve hesabı canlandırma saldırılarına açık bıraktığını unutmayın.
2. Güvensiz Programı Test Etme
Hesabını kapatmayı başaran bir saldırgan, redeem_rewards_insecure
'i birden fazla kez çağırabilir ve hak ettiklerinden daha fazla ödül talep edebilir.
Zaten bu güvenlik açığını sergileyen bazı başlangıç testleri yazılmış. tests
dizinindeki closing-accounts.ts
dosyasına bir bakın. 'before' fonksiyonunda bazı ayarlamalar var, ardından attacker
için yeni bir piyango kaydı oluşturan bir test var.
Son olarak, bir saldırganın hesabı canlı tutmayı nasıl başarabileceğini ve ardından ödülleri nasıl tekrar talep edebileceğini gösteren bir test var. O test şu şekildedir:
it("attacker can close + refund lottery acct + claim multiple rewards", async () => {
// claim multiple times
for (let i = 0; i < 2; i++) {
const tx = new Transaction();
// instruction claims rewards, program will try to close account
tx.add(
await program.methods
.redeemWinningsInsecure()
.accounts({
lotteryEntry: attackerLotteryEntry,
user: attacker.publicKey,
userAta: attackerAta,
rewardMint: rewardMint,
mintAuth: mintAuth,
tokenProgram: TOKEN_PROGRAM_ID,
})
.instruction(),
);
// user adds instruction to refund dataAccount lamports
const rentExemptLamports =
await provider.connection.getMinimumBalanceForRentExemption(
82,
"confirmed",
);
tx.add(
SystemProgram.transfer({
fromPubkey: attacker.publicKey,
toPubkey: attackerLotteryEntry,
lamports: rentExemptLamports,
}),
);
// send tx
await sendAndConfirmTransaction(provider.connection, tx, [attacker]);
await new Promise(x => setTimeout(x, 5000));
}
const ata = await getAccount(provider.connection, attackerAta);
const lotteryEntry =
await program.account.lotteryAccount.fetch(attackerLotteryEntry);
expect(Number(ata.amount)).to.equal(
lotteryEntry.timestamp.toNumber() * 10 * 2,
);
});
Bu test şunları yapmaktadır:
- Kullanıcının ödüllerini talep etmek için
redeem_rewards_insecure
'i çağırır. - Aynı işlemde, hesabı gerçekten kapatmadan önce kullanıcının
lottery_entry
'sini iade etmek için bir talimat ekler. - Bir kez daha ödülle birlikte 1 ve 2 adımlarını başarıyla tekrarlar.
Uyarı: Teorik olarak, a) programda daha fazla ödül kalmadığı veya b) biri durumu fark edip düzeltmediği sürece, 1-2 adımlarını sonsuz bir şekilde tekrarlayabilirsiniz. Bu, herhangi bir gerçek programda ciddi bir sorun olurdu çünkü kötü niyetli bir saldırganın tüm ödül havuzunu boşaltmasına olanak tanır.
3. redeem_rewards_secure
talimatını oluşturun
Bunun olmasını önlemek için, piyango hesabını Anchor close
kısıtlaması kullanarak güvenli bir şekilde kapatan yeni bir talimat oluşturacağız. Eğer isterseniz, bunu kendiniz deneyebilirsiniz.
Yeni hesap doğrulama yapısı RedeemWinningsSecure
şöyle görünmelidir:
#[derive(Accounts)]
pub struct RedeemWinningsSecure<'info> {
// program expects this account to be initialized
#[account(
mut,
seeds = [user.key().as_ref()],
bump = lottery_entry.bump,
has_one = user,
close = user
)]
pub lottery_entry: Account<'info, LotteryAccount>,
#[account(mut)]
pub user: Signer<'info>,
#[account(
mut,
constraint = user_ata.key() == lottery_entry.user_ata
)]
pub user_ata: Account<'info, TokenAccount>,
#[account(
mut,
constraint = reward_mint.key() == user_ata.mint
)]
pub reward_mint: Account<'info, Mint>,
///CHECK: mint authority
#[account(
seeds = [MINT_SEED.as_bytes()],
bump
)]
pub mint_auth: AccountInfo<'info>,
pub token_program: Program<'info, Token>
}
Tamamen aynı olmalıdır, ancak lottery_entry
hesabı üzerinde ek bir close = user
kısıtlaması bulunmaktadır. Bu, Anchor’ın hesap verilerini sıfırlayarak, lamportları user
hesabına aktararak ve hesap ayırıcısını CLOSED_ACCOUNT_DISCRIMINATOR
olarak ayarlayarak hesabı kapatmasını söyleyecektir. Bu son adım, hesabın zaten kapanmaya çalışıldığında tekrar kullanılmasını engelleyecektir.
Ardından, minting CPI'sini token programına yardımcı olmak için yeni RedeemWinningsSecure
yapısında bir mint_ctx
yöntemi oluşturabiliriz.
impl<'info> RedeemWinningsSecure<'info> {
pub fn mint_ctx(&self) -> CpiContext<'_, '_, '_, 'info, MintTo<'info>> {
let cpi_program = self.token_program.to_account_info();
let cpi_accounts = MintTo {
mint: self.reward_mint.to_account_info(),
to: self.user_ata.to_account_info(),
authority: self.mint_auth.to_account_info()
};
CpiContext::new(cpi_program, cpi_accounts)
}
}
Son olarak, yeni güvenli talimatın mantığı şöyle görünmelidir:
pub fn redeem_winnings_secure(ctx: Context<RedeemWinningsSecure>) -> Result<()> {
msg!("Ödülleri hesaplıyoruz");
let amount = ctx.accounts.lottery_entry.timestamp as u64 * 10;
msg!("{} jetonları ödül olarak basılıyor", amount);
// program imzacı tohumları
let auth_bump = *ctx.bumps.get("mint_auth").unwrap();
let auth_seeds = &[MINT_SEED.as_bytes(), &[auth_bump]];
let signer = &[&auth_seeds[..]];
// kullanıcıya basarak ödülleri geri al
mint_to(ctx.accounts.mint_ctx().with_signer(signer), amount)?;
Ok(())
}
Bu mantık, talep eden kullanıcının ödüllerini hesaplar ve ödülleri aktarır. Ancak, hesap doğrulama yapısındaki close
kısıtlaması nedeniyle saldırganın bu talimatı birden fazla kez çağırması beklenemez.
4. Programı Test Etme
Yeni güvenli talimatımızın test edilmesi için iki kez redeemingWinningsSecure
'i çağırmaya çalışan yeni bir test oluşturalım. İkinci çağrının hata fırlatmasını bekliyoruz.
it("attacker cannot claim multiple rewards with secure claim", async () => {
const tx = new Transaction();
// instruction claims rewards, program will try to close account
tx.add(
await program.methods
.redeemWinningsSecure()
.accounts({
lotteryEntry: attackerLotteryEntry,
user: attacker.publicKey,
userAta: attackerAta,
rewardMint: rewardMint,
mintAuth: mintAuth,
tokenProgram: TOKEN_PROGRAM_ID,
})
.instruction(),
);
// user adds instruction to refund dataAccount lamports
const rentExemptLamports =
await provider.connection.getMinimumBalanceForRentExemption(
82,
"confirmed",
);
tx.add(
SystemProgram.transfer({
fromPubkey: attacker.publicKey,
toPubkey: attackerLotteryEntry,
lamports: rentExemptLamports,
}),
);
// send tx
await sendAndConfirmTransaction(provider.connection, tx, [attacker]);
try {
await program.methods
.redeemWinningsSecure()
.accounts({
lotteryEntry: attackerLotteryEntry,
user: attacker.publicKey,
userAta: attackerAta,
rewardMint: rewardMint,
mintAuth: mintAuth,
tokenProgram: TOKEN_PROGRAM_ID,
})
.signers([attacker])
.rpc();
} catch (error) {
console.log(error.message);
expect(error);
}
});
anchor test
komutunu çalıştırarak testin geçtiğini görebilirsiniz. Çıktı aşağıdaki şekilde görünecektir:
closing-accounts
✔ Piyangoya gir (451ms)
✔ saldırgan kapanabilir + piyango hesabı iade + birden fazla ödül talep edebilir (18760ms)
AnchorError caused by account: lottery_entry. Error Code: AccountDiscriminatorMismatch. Error Number: 3002. Error Message: 8 byte discriminator did not match what was expected.
✔ saldırgan güvenli talep ile birden fazla ödül talep edemez (414ms)
Not: Bu kötü niyetli kullanıcının hesabını tamamen iade etmelerini engellemiyor - yalnızca programımızın hesabı kapatılırken kapalı bir hesabı yanlışlıkla yeniden kullanmasını önlüyor. Henüz
force_defund
talimatını uygulamadık, ancak yapabiliriz. Eğer denemek isterseniz, kendiniz bir şans verin!
Hesapları kapatmanın en basit ve en güvenli yolu, Anchor'ın close
kısıtlamasını kullanmaktır. Eğer bu kısıtlamayı kullanamayacak kadar özel bir davranışa ihtiyacınız varsa, programınızın güvenliğini sağlamak için bu işlevselliği tekrar etmeyi unutmayın.
Son çözüm koduna bakmak isterseniz, onu aynı repodaki solution
dalında bulabilirsiniz.
repo.
Meydan Okuma
Bu birim içindeki diğer derslerde olduğu gibi, bu güvenlik açığından kaçınma fırsatınız, kendi veya diğer programlarınızı denetlemekle ilgilidir.
Hesapların kapatıldığında canlandırma saldırılarına maruz kalmadıklarından emin olmak için en az bir programı gözden geçirmek için biraz zaman ayırın.
Öneri: Unutmayın, birinin programında bir hata veya kötüye kullanım fark ederseniz, lütfen onları bilgilendirin! Kendi programınızda bir seçenek bulursanız, hemen düzeltin.
Kodunuzu GitHub'a gönderin ve bize bu ders hakkında ne düşündüğünüzü söyleyin!